Skip to content

Conversation

@andremion
Copy link
Contributor

@andremion andremion commented Oct 23, 2025

🎯 Goal

  • Send message delivery receipts, supporting local data buffering, batched reporting, event handling, and UI indicators.
  • Marks messages as delivered on:
    • Single channel query result (first page only)
    • Channel list query result
    • Push notification received
    • WS message.new event

https://linear.app/stream/issue/AND-769

🛠 Implementation details

Client module

  • New persistence and repository for delivery receipts (MessageReceiptDao/Entity/Repository, ChatClientDatabase, DateConverter).
  • New delivery reporting pipeline (MessageReceiptManager, MessageReceiptReporter) with 1s buffer and max 100 confirmations per call.
  • message.delivered WS event support.
  • Introduced new Channel extensions: readsOf, deliveredReadsOf.
  • Introduced ChatClient.markMessageAsDelivered to mark a message as delivered for the current user.
  • PrivacySettings.delivery_receipts support.
  • Channel read state extended with last_delivered_at and last_delivered_message_id.

Delivery receipts are:

  • Buffered and sent every 1s, max 100 per request.
  • Not sent for own messages, not sent if already read or already delivered.
  • Reconciled against reads (reads supersede delivered).

Privacy:

  • Suppressed if ownUser.privacySettings.deliveryReceipts.enabled == false.

UI:

  • Compose and XML indicators updated to support the new grey double tick (delivered)
  • Unified logic to hide the message status indicator when a message is deleted / ephemeral / sync status failed permanently.

🎨 UI Changes

1-to-1 chat

Screen.Recording.2025-11-04.at.08.43.56.mov

Group chat

Screen.Recording.2025-11-04.at.10.06.56.mov

🧪 Testing

  • Open the channel list from two devices connected with different users.
  • Send a message from user A to user B.
  • Long-press on the new message from user A.
  • Click on Message Info.
  • The message should be delivered.
  • Enter the chat from user B.
  • From user A, notice that the message should now be read.

@andremion andremion added the core label Oct 23, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Oct 23, 2025

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 3.23 MB 3.25 MB 0.02 MB 🟢
stream-chat-android-offline 3.45 MB 3.48 MB 0.02 MB 🟢
stream-chat-android-ui-components 10.54 MB 10.57 MB 0.03 MB 🟢
stream-chat-android-compose 12.77 MB 12.79 MB 0.02 MB 🟢

@andremion andremion force-pushed the AND-769-message-delivery-status branch 3 times, most recently from cbf13fa to 0b052a3 Compare October 27, 2025 15:21
@github-actions
Copy link
Contributor

github-actions bot commented Oct 27, 2025

DB Entities have been updated. Do we need to upgrade DB Version?
Modified Entities :

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/entity/MessageReceiptEntity.kt
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/UserEntity.kt

@andremion andremion force-pushed the AND-769-message-delivery-status branch 6 times, most recently from d6aebd9 to 89655f6 Compare November 4, 2025 10:23
@andremion andremion requested a review from Copilot November 4, 2025 14:17
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This pull request adds delivery status indicators for messages in the Stream Chat SDK. It introduces a new "delivered" state between "sent" and "read" states, allowing the UI to display when messages have been delivered to recipients but not yet read.

Key changes:

  • Added isMessageDelivered property to message and channel state classes
  • Introduced new delivery status icon and UI indicators
  • Updated database schema version to support delivery events persistence
  • Added new drawable resource for the delivered state (double checkmark in grey)

Reviewed Changes

Copilot reviewed 116 out of 122 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt Added lastDeliveredAt and lastDeliveredMessageId properties
stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt Added deliveryEventsEnabled configuration flag
stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt Added delivery receipts privacy settings
stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_check_double_grey.xml New drawable for delivered status indicator
stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt Added isMessageDelivered property to MessageItem
stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt Updated to support delivered status icon
stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt Added new MessageFooterStatusIndicator overload with params
stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt Incremented database version to 96
stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt Added MessageDeliveredEvent to ChannelUserRead conversion

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1407 to +1415
if (params.messageItem.isMessageDelivered) {
MessageReadStatusIcon(
modifier = params.modifier,
message = params.messageItem.message,
isMessageRead = params.messageItem.isMessageRead,
isMessageDelivered = params.messageItem.isMessageDelivered,
readCount = params.messageItem.messageReadBy.size,
)
} else {
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional logic at line 1407 checks isMessageDelivered, but the message could be read (which implies delivered). The condition should check for both read and delivered states to ensure MessageReadStatusIcon is always called with the isMessageDelivered parameter, maintaining consistency. Consider simplifying this to always pass the isMessageDelivered parameter.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done to prevent breaking changes in the component factory.
If isMessageDelivered == true, that means we can call the new overloaded function; otherwise, we have to call the deprecated one.

@andremion andremion force-pushed the AND-769-message-delivery-status branch from 19d96be to 7e134c3 Compare November 4, 2025 14:44
@andremion andremion marked this pull request as ready for review November 4, 2025 14:44
@andremion andremion requested a review from a team as a code owner November 4, 2025 14:44
* @param userId The ID of the user whose read state is to be retrieved.
* @return The [ChannelUserRead] object representing the user's read state, or null if not found.
*/
public fun Channel.userRead(userId: UserId): ChannelUserRead? =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering if need to expose these new methods as public - do you think they would be useful for integrators, without the risk of us having to change the implementation/Public API?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say so. But I'm fine with making it internal and exposing it on demand.
I will change it then.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really have a very strong opinion here - if you think this would be a stable API, we can leave it exposed.

public fun Channel.readsOf(message: Message): List<ChannelUserRead> =
read.filter { read ->
read.user.id != message.user.id &&
read.lastRead >= message.getCreatedAtOrThrow()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use the non-throwing method here, like getCreatedAtOrDefault()

/**
* A plugin that marks messages as delivered when channels are queried.
*/
internal class MessageDeliveredPlugin(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it would be possible to avoid introducing new Plugins, as it is something that we want to completely remove in the future. I think that this introduces another layer of complexity over the ChatClient. Wouldn't it be possible to achieve the same thing by calling the appropriate MessageReceiptManager methods from the corresponding ChatClient methods (queryChannels/queryChannel)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn’t say the long-term goal is to remove plugins entirely, but rather to make them internal and non-optional.
In this case, regardless of the approach, the implementation would likely live outside ChatClient — which is already close to 5K LOC.
Keeping it as an internal plugin for now helps isolate responsibilities and prevents adding more logic directly to ChatClient.
We can revisit and consolidate it later as part of the broader plugin refactoring.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we are reducing not more than 3 lines at best in the client by introducing the Plugin - two invocations of the MessageReceiptManager. I am generally against the introduction of new Plugins, because they are executing crucial business logic as a side-effect, and it's really difficult to locate that logic, unless you explicitly know about the existence of that specific Plugin.

But nevertheless, let's keep it like this, but I definitely agree that should revisit this as part of the planned refactor.

* @see [markChannelsAsDelivered] for the conditions to mark a message as delivered.
*/
suspend fun markMessageAsDelivered(message: Message) {
val currentUser = getCurrentUser() ?: run {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this work for the case when push notifications are received, if the app is killed? In that case, we wouldn't have a connected user to check against.

internal class MessageReceiptManager(
private val now: () -> Date,
private val getCurrentUser: () -> User?,
private val repositoryFacade: RepositoryFacade,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we somehow avoid the dependency on the OfflinePlugin RepositoryFacade?
To be honest I am mostly concerned about the case where the OfflinePlugin is not present, and we have to make calls to getMessage/getChannel, which if happens often enough, we could end up in a rate limit error. (Note the case where a customer doesn't have the OfflinePlugin, and is in a channel where multiple message.new events are received in short amount of time - we could end up firing getChannel lots of times)

Can we somehow remove the need for the retrieveChannel/retrieveMessage methods in the manager?
I am thinking in the direction of:

  • Instead of depending on the OfflinePlugin/HTTP calls, why don't we store some channel config in the client database after fetching the channel(s). Later, when we call markMessageAsDelivered, we can check that stored config, to make sure we should mark the message.

This whole logic should be easier when the state/db is moved down to the client...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. In that case, we need to store more than just the channel config. We use message data and channel read, too.
I will include Message, Channel, ChannelUserRead, and ChannelConfig in the client database then.
Expects an even bigger PR 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm but my question still stands: Isn't hitting rate limit errors a real possibility?

Side note: We can also access some of the State plugin data from Client via:

logicRegistry
            ?.channelStateLogic("type", "id")
            ?.listenForChannelState()
            ?.toChannel() // ?.getMessageById(id)

Perhaps this can be utilised to check for existing channel data (but this still wouldn't cover the case when the client is used without the state plugin)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channel state is not completely filled when the app is not in memory (for example, the read list is empty). So we can't rely on it.
We can make the delivery receipts feature require the offline plugin, so we can rely on the local storage instead (local storage will be available soon as a built-in SDK feature).


internal data class MessageReceipt(
val messageId: String,
val type: String,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the type needed? As far as I can see, if is always set to delivery.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is to support other types of message receipt, like read, in the future.
The current read implementation is quite confusing and hard to maintain 😓

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just afraid that this will become legacy code, if we don't actually update this to support the different receipts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove it then

/**
* Repository that aggregates all internal repositories used by [ChatClient].
*/
internal class ChatClientRepository(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure whether we really need this class - wouldn't it just introduce another layer of complexity?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is to avoid having many constructor parameters when a class depends on multiple repositories.
For example, when the manager depends on MessageRepository, ChannelRepository, and MessageReceiptRepository, we would have to pass 3 dependencies instead of 1

@andremion andremion force-pushed the AND-769-message-delivery-status branch from 7e134c3 to ce8df3f Compare November 7, 2025 12:06
Comment on lines +162 to +164
read.filter { read ->
read.user.id != message.user.id &&
(read.lastDeliveredAt ?: NEVER) >= message.getCreatedAtOrDefault(NEVER)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should probably want to also check for lastRead and promote it to delivered for supporting old messages where lastDeliveredAt is not present.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Channel.deliveredReadsOf function returns a list of ChannelUserRead objects representing which ones have delivered the given message, regardless of the read state. If lastDeliveredAt is null, as in old messages, it is not returned in the list.
For reads, we can use Channel.readsOf function, which checks specifically read states, regardless of the delivered states.
That's to give customers the flexibility to list who has read messages and who has delivered messages, exclusively.
If they want to list delivered reads that are not read, they can do:
channel.deliveredReadsOf(message) - channel.readsOf(message)

@andremion andremion force-pushed the AND-769-message-delivery-status branch 3 times, most recently from e71c17a to 63b7e99 Compare November 11, 2025 15:30
@andremion andremion requested a review from Copilot November 11, 2025 16:01
Copilot finished reviewing on behalf of andremion November 11, 2025 16:02
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Copilot reviewed 131 out of 137 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@andremion andremion force-pushed the AND-769-message-delivery-status branch from 63b7e99 to e235eb4 Compare November 11, 2025 17:22
This change prevents sending message delivery receipts for messages that are either shadowed or sent by a user who has been muted by the current user.
… push messages before they are processed by the SDK.
@andremion andremion force-pushed the AND-769-message-delivery-status branch from 0b179a7 to 716b933 Compare November 12, 2025 10:40
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
77.4% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

tokenManager.setTokenProvider(tokenProvider)
appSettingsManager.loadAppSettings()
warmUp()
messageReceiptReporter.start()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also have a corresponding stop() method? Or do we rely on cancelling the scope to cancel the running job?

fun onPushMessage(
pushMessage: PushMessage,
pushNotificationReceivedListener: PushNotificationReceivedListener =
PushNotificationReceivedListener { _, _ -> },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that the default PushNotificationReceivedListener is no longer needed.

getDefaultActionsProvider(context),
notificationBuilderTransformer: (NotificationCompat.Builder, ChatNotification) -> NotificationCompat.Builder =
{ builder, _ -> builder },
onNewMessage: (pushMessage: PushMessage) -> Boolean = { false },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure whether this should also control the default showing of push notifications, because it is already configurable by extending the NotificationHandler. Should this maybe just be a side effect?

Also I believe that this method will be called for any PN, not just for message.new, so for the delivery receipt case, the integrators would also need to check the type of the message.

fun build(context: Context) = Room.databaseBuilder(
context = context.applicationContext,
klass = ChatClientDatabase::class.java,
name = "stream_chat_client.db",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the .db suffix in the name of the DB?

Also another thing that I missed initially: Should we have different DB files per user (similar to the OfflinePlugin DB)?

import androidx.room.TypeConverter
import java.util.Date

internal class DateConverter {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small point for the future, no need to change anything now: We could potentially unify the common converters with the ones from the offline plugin.

parentMessageId = message.parentId,
)
},
onNewMessage = { pushMessage ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned in a previous comment, I believe that this will be called for every PN type -> shouldn't we do this just for message.new?

public fun markMessageAsDelivered(messageId: String): Call<Unit> =
CoroutineCall(userScope) {
messageReceiptManager.markMessageAsDelivered(messageId)
Result.Success(Unit)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we maybe return Result.Error from this method, if the message cannot be marked as delivered (ex. if some of the preconditions are not satisfied)?

}

/**
* Request to mark the message with the given id as delivered if:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to document that this method might attempt to internally call getMessage/getChannel, just for transparency sake. Or at least it would be good to document this in the Docs page as well. I think that it is an important information, that integrators should know before using this method.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants